// Secure server-side database based session manager with client signature checksum support
// Copyright (C) 2011-2012 Jani Kajala. Licensed under MIT-license.
// Dependencies: Node.js (only core modules)

var crypto = require('crypto');
var util = require('util');
var url = require('url'); // for parse()

//--------------------------------------------------------------------
// Helpers (not visible outside this module)
//--------------------------------------------------------------------

/** Session document prototype. */
var SessionDoc = {
	_id: 'session id',
	data: 'session data',
	sum: 'security checksum calculated from request properties and secret key'
};

/** Returns session id from request cookie/query-string or false if not found */
function getSid( req, key ) {
	var cookies = req.headers['cookie']; // jsid=4f60b5eb9d14fd5801000001; nses=545
	if ( cookies ) {
		cookies = cookies.split(';');
		var ncookies = cookies.length;
		var prefix = key+'=';
		var nprefix = prefix.length;
		for ( var i = 0 ; i < ncookies ; ++i ) {
			var c = cookies[i].trim();
			if ( c.substr(0,nprefix) === prefix ) {
				return c.substr(nprefix);
			}
		}
	}
	if ( typeof req.query == 'object' )
		return req.query[key];
	else
		return url.parse(req.url,true).query[key];
	return false;
};

/** Calculates checksum from request properties and secret key */
function sum( req, secret ) {
	// remoteAddress missing/invalid from proxied requests 
	var src = req.connection.remoteAddress;
	if (typeof req.headers['x-real-ip'] === 'string')
		src = req.headers['x-real-ip'];
	
	if (src) {
		var i = src.indexOf('.');
		if ( i > 0 ) // '.' -> IPv4, but use only X.Y of X.Y.Z.W since client might be behind NAT
			i = src.indexOf('.',i+1); 
		else // ':' -> IPv6, just chop it to half
			i = src.length/2;
		src = src.substr(0,i);
	}
	else {
		src = '';
	}
	src += req.headers['user-agent'] + secret;
	return crypto.createHash('md5').update(src).digest('hex');
}

//--------------------------------------------------------------------
// Session manager
//--------------------------------------------------------------------

/**
 * Constructs session manager.
 *
 * Session store uses following methods for setting and getting session related data:
 * - insert(data,next), where next(err,sid)
 * - set(sid,data,next), where next(err)
 * - get(sid,next), where next(err,data)
 * - remove(sid), where next(err)
 *
 * @param opt Options:
 * @param opt.key Session key name
 * @param opt.secret Seed string for session id generation. Use some random sequence for this.
 * @param opt.verbose More debug output. Default: false.
 * @param store Session store. See session-*.js.
 */
function Session( opt, store ) {
	var self = this;

	if (!store)
		throw new Exception( "Session data store undefined" );

	for (var k in opt)
		self[k] = opt[k];

	var reqs = ['key','secret'];
	for (var i = 0 ; i < reqs.length ; ++i) {
		var k = reqs[i];
		if ( typeof opt[k] == 'undefined' )
			throw new Error( "Session: opt."+k+" required in constructor" );
	}
	
	var defaults = {
		verbose: false
	};
	for (var k in defaults) {
		if ( typeof opt[k] == 'undefined' )
			self[k] = defaults[k];
	}

	this.store = store;
}

/**
 * Starts a new session and sets response cookie session id.
 * @param req ServerRequest
 * @param res ServerResponse. Receives session data to res.session.
 * @param next Callback next(err).
 */
Session.prototype.restart = function(req, res, next) {
	var self = this;

	if (this.verbose) {
		console.log( "Session.restart: headers:" );
		console.dir( req.headers );
	}

	var ses = {data:{}, sum:sum(req,this.secret)};
	this.store.insert( ses, function(err,sid) {
		if (err) {console.error("ERROR: "+err+", sid="+sid); return next(err);}
		
		var expires = new Date( (new Date()).getTime() + self.store.expires*1000 ).toUTCString(); 
		req.sid = sid;
		req.session = {};
		res.setHeader( 'Set-Cookie', self.key+'='+sid+'; Path=/; Expires='+expires+'; HttpOnly' );
		next();
	} );
}; 

/** 
 * Starts the session if valid one doesn't exist and retrieves sessions data. 
 * Retrieved session data is stored to res.session.
 * @param req ServerRequest. Receives session data to req.session.
 * @param res ServerResponse. Receives session data to res.session.
 * @param next Callback next(err).
 */
Session.prototype.start = function( req, res, next ) {
	var self = this;
	var sid = getSid(req,this.key);
	if (sid) {
		if (this.verbose) console.log( "Session.start: Found cookie sid candidate "+sid+" ("+(''+sid).length+" bytes)" );
		
		this.store.get( sid, function(err,s) {
			if (err) console.error( err );
			
			if (self.verbose) {
				console.dir( s );
				console.log( "DEBUG: s="+(s?1:0) );
				if (s) console.log( "DEBUG: s.sum="+s.sum );
				console.log( "DEBUG: sum="+sum(req,self.secret) );
			}
			
			if (s && s.sum == sum(req,self.secret) ) {
				if (self.verbose) console.log( "Session.start: Session valid, sid="+s._id );
				req.sid = s._id;
				req.session = s.data;
				next();
			}
			else {
				if (self.verbose) {
					if (s)
						console.log( "Session.start: Session exists but sum incorrect, restarting (sum was "+s.sum+", should have been "+sum(req,self.secret)+")" );
					else
						console.log( "Session.start: Session object missing making session invalid, restarting" );
				}
				self.store.remove( sid );
				self.restart( req, res, next );
			}
		} );
	}
	else {
		if (this.verbose) console.log( "Session.start: Session missing, restarting" );
		this.restart( req, res, next );
	}
};

/**
 * Saves session data if session is valid.
 * @param req ServerRequest
 * @param res ServerResponse
 * @param next Callback next(err).
 */
Session.prototype.save = function( req, res, next ) {
	var s = {data:req.session, sum:sum(req,this.secret)};
	if (this.verbose) console.log( "Session.save: sid="+req.sid+" "+JSON.stringify(s) );
	this.store.set( req.sid, s, next );
};

/** 
 * Terminates any active session. 
 * @param req ServerRequest
 * @param res ServerResponse
 * @param next Callback next(err).
 */
Session.prototype.destroy = function( req, res, next ) {
	res.setHeader( 'Set-Cookie', this.key+'=; Path=/; Expires='+(new Date(0)).toUTCString() );
	var sid = (req.sid ? req.sid : getSid(req,this.key));
	req.sid = false;
	req.session = null;
	if (sid)
		this.store.remove( sid, next );
	else if (typeof next == 'function')
		next();
};

//------------------------------------------------------------------------------------------------
// module.exports
//------------------------------------------------------------------------------------------------

module.exports = Session;
